agentmux_srv\backend\layout/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pure layout-tree helper functions for the srv E.4.B reducer.
5//!
6//! Phase 4 of docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md.
7//! Ports the 11 pure-ish action handlers from
8//! `frontend/layout/lib/layoutTree.ts` to Rust. These functions take
9//! `&mut LayoutNode` (the tree root) and operation params; they have no
10//! I/O and no side effects. The reducer arms (Phase 5) will call these.
11//!
12//! Test oracle: the 30 tests in `frontend/layout/tests/layoutTree.test.ts`
13//! (shipped in PR #686) — the Rust implementations must produce identical
14//! state transitions for identical inputs.
15
16use agentmux_common::{FlexDirection, LayoutNode, ResizeOp, SplitPosition};
17use uuid::Uuid;
18
19// ── Error type ─────────────────────────────────────────────────────────────
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum LayoutError {
23    NodeNotFound { id: String },
24    /// Caller tried to swap a node with itself.
25    SelfSwap,
26    /// Caller tried to swap, move, or magnify the root node.
27    RootCannotBeTarget,
28    /// A resize op had size outside [0.0, 100.0].
29    InvalidSize { node_id: String, size: f32 },
30    /// index_arr was empty or led to a non-existent location.
31    InvalidIndexPath,
32}
33
34impl std::fmt::Display for LayoutError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        match self {
37            Self::NodeNotFound { id } => write!(f, "node not found: {}", id),
38            Self::SelfSwap => write!(f, "cannot swap a node with itself"),
39            Self::RootCannotBeTarget => write!(f, "root node cannot be the target of this operation"),
40            Self::InvalidSize { node_id, size } => write!(f, "invalid size {:.2} for node {}", size, node_id),
41            Self::InvalidIndexPath => write!(f, "index_arr is empty or points to a non-existent location"),
42        }
43    }
44}
45
46// ── Constants ──────────────────────────────────────────────────────────────
47
48/// Maximum children before `insert_node`'s heuristic descends.
49/// Mirrors `frontend/layout/lib/layoutTree.ts::DEFAULT_MAX_CHILDREN`.
50pub const DEFAULT_MAX_CHILDREN: usize = 5;
51
52/// Default flex size for newly-inserted nodes.
53/// Mirrors `frontend/layout/lib/types.ts::DefaultNodeSize`.
54pub const DEFAULT_NODE_SIZE: f32 = 10.0;
55
56// ── Flex-direction utilities ───────────────────────────────────────────────
57
58/// Reverse a flex direction (Row ↔ Column).
59/// Mirrors `frontend/layout/lib/layoutNode.ts::reverseFlexDirection`.
60fn reverse_flex_direction(dir: FlexDirection) -> FlexDirection {
61    match dir {
62        FlexDirection::Row => FlexDirection::Column,
63        FlexDirection::Column => FlexDirection::Row,
64    }
65}
66
67/// If `node` is a leaf (has `data`), promote it to a group by wrapping its
68/// data in an intermediate child. Mirrors TypeScript `addIntermediateNode`
69/// in `frontend/layout/lib/layoutTree.ts`.
70///
71/// After this call:
72/// - `node.data` is `None` and `node.children` is non-empty.
73/// - The intermediate child receives the node's ORIGINAL ID so any
74///   frontend reference (e.g. `focusedNodeId`) still points at the
75///   leaf data after promotion.
76/// - The group wrapper gets a fresh UUID, and its flex direction is
77///   the reverse of the original leaf's so the new sibling axis is
78///   perpendicular to the parent's (matches TS layout semantics).
79///
80/// No-op when `node` is already a group (no `data`).
81fn ensure_group_node(node: &mut LayoutNode) {
82    if node.data.is_some() {
83        let old_id = node.id.clone();
84        let old_flex = node.flex_direction;
85        let intermediate = LayoutNode {
86            id: old_id,
87            flex_direction: reverse_flex_direction(old_flex),
88            size: DEFAULT_NODE_SIZE,
89            data: node.data.take(),
90            // Move `extra` (forward-compat catch-all from #688/#689) to the
91            // intermediate so unknown fields the frontend wrote to the leaf
92            // travel with the data, not stay on the new group wrapper.
93            // Mirrors the same transfer in `delete_recursive` line below.
94            extra: std::mem::take(&mut node.extra),
95            ..Default::default()
96        };
97        node.id = Uuid::new_v4().to_string();
98        node.children.push(intermediate);
99    }
100}
101
102// ── Tree traversal ─────────────────────────────────────────────────────────
103
104/// Find a node by id (immutable).
105pub fn find_node_by_id<'a>(tree: &'a LayoutNode, id: &str) -> Option<&'a LayoutNode> {
106    if tree.id == id {
107        return Some(tree);
108    }
109    for child in &tree.children {
110        if let Some(found) = find_node_by_id(child, id) {
111            return Some(found);
112        }
113    }
114    None
115}
116
117/// Find a node by id (mutable).
118pub fn find_node_by_id_mut<'a>(tree: &'a mut LayoutNode, id: &str) -> Option<&'a mut LayoutNode> {
119    if tree.id == id {
120        return Some(tree);
121    }
122    for child in &mut tree.children {
123        if let Some(found) = find_node_by_id_mut(child, id) {
124            return Some(found);
125        }
126    }
127    None
128}
129
130// ── Insert-location heuristic ──────────────────────────────────────────────
131
132/// Candidate for insertion location, collected during tree traversal.
133struct InsertCandidate<'a> {
134    node: &'a LayoutNode,
135    index: usize,
136    depth: usize,
137}
138
139/// Find the best insertion location using the TypeScript scoring heuristic
140/// (`Math.pow(depth, index + maxChildren)`). Collects all candidates, then
141/// sorts by ascending score. Mirrors `findNextInsertLocation` in
142/// `frontend/layout/lib/layoutNode.ts`.
143fn find_next_insert_location(
144    tree: &LayoutNode,
145    max_children: usize,
146) -> (&LayoutNode, usize) {
147    let mut candidates = Vec::new();
148    collect_insert_candidates(tree, max_children, 1, &mut candidates);
149    candidates.sort_by(|a, b| {
150        let a_score = (a.depth as f64).powi((a.index + max_children) as i32);
151        let b_score = (b.depth as f64).powi((b.index + max_children) as i32);
152        a_score.partial_cmp(&b_score).unwrap_or(std::cmp::Ordering::Equal)
153    });
154    candidates
155        .into_iter()
156        .next()
157        .map(|c| (c.node, c.index))
158        .unwrap_or((tree, tree.children.len()))
159}
160
161fn collect_insert_candidates<'a>(
162    node: &'a LayoutNode,
163    max_children: usize,
164    depth: usize,
165    out: &mut Vec<InsertCandidate<'a>>,
166) {
167    // Leaf node (has data but no children) — TS returns index 1.
168    if node.data.is_some() && node.children.is_empty() {
169        out.push(InsertCandidate { node, index: 1, depth });
170        return;
171    }
172    if node.children.len() < max_children {
173        out.push(InsertCandidate {
174            node,
175            index: node.children.len(),
176            depth,
177        });
178    }
179    // TS iterates children in REVERSE order.
180    for child in node.children.iter().rev() {
181        collect_insert_candidates(child, max_children, depth + 1, out);
182    }
183}
184
185// ── Core operations ────────────────────────────────────────────────────────
186
187/// Insert `node` using the TypeScript scoring heuristic (up to
188/// DEFAULT_MAX_CHILDREN per node before descending). If the target location
189/// is a leaf, it is promoted to a group first via `ensure_group_node`.
190/// Mirrors `insertNode` / `addChildAt` in `frontend/layout/lib/layoutTree.ts`.
191pub fn insert_node(root: &mut LayoutNode, node: LayoutNode) {
192    let loc_id = find_next_insert_location(root, DEFAULT_MAX_CHILDREN).0.id.clone();
193    if let Some(target) = find_node_by_id_mut(root, &loc_id) {
194        ensure_group_node(target);
195        target.children.push(node);
196    }
197}
198
199/// Insert `node` at the location identified by `index_arr` — an ordered
200/// sequence of child indices (e.g. `[0, 2]` = root.children[0].children
201/// at index 3). The node is inserted AFTER the position identified by the
202/// last index.
203///
204/// Mirrors `findInsertLocationFromIndexArr` in
205/// `frontend/layout/lib/layoutNode.ts`:
206/// - Each segment is **clamped** to `[0, children.len() - 1]`. Out-of-range
207///   indices do NOT error — they resolve to the last child slot.
208/// - Descent **stops at a leaf**. Any remaining segments after we hit a node
209///   with no children are ignored, and the leaf becomes the insert target
210///   (where `ensure_group_node` then promotes it). This matches the TS
211///   oracle's `if (indexArr.length == 0 || !node.children) return ...`.
212///
213/// This tolerance is required for "identical state transitions" with the
214/// frontend reducer under concurrent edits — the frontend may issue a
215/// stale or over-deep `indexArr` against a tree that has since shrunk.
216pub fn insert_node_at_index(
217    root: &mut LayoutNode,
218    node: LayoutNode,
219    index_arr: &[usize],
220) -> Result<(), LayoutError> {
221    if index_arr.is_empty() {
222        return Err(LayoutError::InvalidIndexPath);
223    }
224    let mut current = root as *mut LayoutNode;
225    let mut clamped_idx: usize = 0;
226    let last_seg = index_arr.len() - 1;
227
228    for (seg_pos, &idx) in index_arr.iter().enumerate() {
229        let cur = unsafe { &mut *current };
230        if cur.children.is_empty() {
231            // Leaf reached early — TS treats `node.children?.length ?? 1`
232            // as 1 here, so any non-negative idx clamps to 0. Stop descent.
233            clamped_idx = 0;
234            break;
235        }
236        clamped_idx = idx.min(cur.children.len() - 1);
237        if seg_pos < last_seg {
238            current = &mut cur.children[clamped_idx] as *mut LayoutNode;
239        }
240    }
241
242    let parent = unsafe { &mut *current };
243    ensure_group_node(parent);
244    let insert_at = (clamped_idx + 1).min(parent.children.len());
245    parent.children.insert(insert_at, node);
246    Ok(())
247}
248
249/// Remove the node with the given id from the tree.
250/// Returns `Ok(())` on success, or `Err(NodeNotFound)` if `node_id` is not
251/// in the tree. Callers are responsible for clearing `focused_node_id` if
252/// needed (spec §7.1). Collapses single-child parents.
253pub fn delete_node(root: &mut LayoutNode, node_id: &str) -> Result<(), LayoutError> {
254    if root.id == node_id {
255        // Root deletion is handled by the caller setting root = None.
256        return Ok(());
257    }
258    match delete_recursive(root, node_id) {
259        true => Ok(()),
260        false => Err(LayoutError::NodeNotFound { id: node_id.to_string() }),
261    }
262}
263
264/// Recursive delete. Returns `true` when the node was found and removed,
265/// `false` when not found.
266fn delete_recursive(node: &mut LayoutNode, target_id: &str) -> bool {
267    let idx = node.children.iter().position(|c| c.id == target_id);
268    if let Some(i) = idx {
269        // Found target as a direct child — remove it.
270        node.children.remove(i);
271        // Collapse: if only one child remains, promote it.
272        // Preserve `extra` (unknown-field catch-all from #688/#689).
273        if node.children.len() == 1 {
274            let sole = node.children.remove(0);
275            node.id = sole.id;
276            node.size = sole.size;
277            node.data = sole.data;
278            node.flex_direction = sole.flex_direction;
279            node.children = sole.children;
280            node.extra = sole.extra;
281        }
282        return true;
283    }
284    // Not a direct child — recurse.
285    for child in &mut node.children {
286        if delete_recursive(child, target_id) {
287            return true;
288        }
289    }
290    false
291}
292
293/// Move the node identified by `node_id` to be the child at `index` under
294/// the node identified by `new_parent_id`. Resets the moved node's size to
295/// DEFAULT_NODE_SIZE unless it stays under the same parent.
296pub fn move_node(
297    root: &mut LayoutNode,
298    node_id: &str,
299    new_parent_id: &str,
300    index: usize,
301) -> Result<(), LayoutError> {
302    if root.id == node_id || root.id == new_parent_id {
303        if new_parent_id == root.id && root.id != node_id {
304            // Moving a child to root-as-parent is valid; proceed.
305        } else if root.id == node_id {
306            return Err(LayoutError::RootCannotBeTarget);
307        }
308    }
309
310    // Validate BOTH node existence AND destination BEFORE any mutation.
311    let node_to_move = find_node_by_id(root, node_id)
312        .ok_or_else(|| LayoutError::NodeNotFound { id: node_id.to_string() })?
313        .clone();
314    // Confirm destination exists while tree is still intact.
315    if find_node_by_id(root, new_parent_id).is_none() {
316        return Err(LayoutError::NodeNotFound { id: new_parent_id.to_string() });
317    }
318    // Confirm destination is NOT a descendant of the node being moved.
319    if find_node_by_id(&node_to_move, new_parent_id).is_some() {
320        return Err(LayoutError::NodeNotFound {
321            id: format!("{} (is a descendant of the moved node {})", new_parent_id, node_id),
322        });
323    }
324
325    let old_parent_id = find_parent_id(root, node_id);
326    let same_parent = old_parent_id.as_deref() == Some(new_parent_id);
327
328    // Capture the node's current index inside the new parent BEFORE the
329    // detach so we can compensate for the source-removal shift below.
330    // Only meaningful when same_parent (otherwise cur_idx is None).
331    let cur_idx_in_new_parent = if same_parent {
332        find_node_by_id(root, new_parent_id)
333            .and_then(|p| p.children.iter().position(|c| c.id == node_id))
334    } else {
335        None
336    };
337
338    // Now it is safe to detach (both endpoints exist).
339    remove_node_from_parent(root, node_id);
340
341    let mut node_with_size = node_to_move;
342    if !same_parent {
343        node_with_size.size = DEFAULT_NODE_SIZE;
344    }
345
346    // Mirror the TypeScript oracle (`moveNode` in
347    // frontend/layout/lib/layoutTree.ts): TS does insert-then-remove with
348    // a `startingIndex` shift, which makes same-parent moves where the
349    // requested index is past the current position resolve to one slot
350    // earlier in the final array (because the to-be-removed source eats
351    // a slot from `index`). We do detach-then-insert here, so we apply
352    // that compensation by decrementing `index` when target > cur_idx.
353    let effective_index = match cur_idx_in_new_parent {
354        Some(ci) if index > ci => index - 1,
355        _ => index,
356    };
357
358    // Insert at new location (destination guaranteed to still exist).
359    let new_parent = find_node_by_id_mut(root, new_parent_id).unwrap();
360    ensure_group_node(new_parent);
361    let insert_at = effective_index.min(new_parent.children.len());
362    new_parent.children.insert(insert_at, node_with_size);
363    Ok(())
364}
365
366fn find_parent_id(root: &LayoutNode, child_id: &str) -> Option<String> {
367    if root.children.iter().any(|c| c.id == child_id) {
368        return Some(root.id.clone());
369    }
370    for child in &root.children {
371        if let Some(pid) = find_parent_id(child, child_id) {
372            return Some(pid);
373        }
374    }
375    None
376}
377
378fn remove_node_from_parent(root: &mut LayoutNode, node_id: &str) {
379    if let Some(idx) = root.children.iter().position(|c| c.id == node_id) {
380        root.children.remove(idx);
381        return;
382    }
383    for child in &mut root.children {
384        remove_node_from_parent(child, node_id);
385    }
386}
387
388/// Swap two nodes (by id). Their sizes travel with them (swap positions
389/// but preserve the size each node had). Neither can be the root.
390pub fn swap_nodes(
391    root: &mut LayoutNode,
392    node1_id: &str,
393    node2_id: &str,
394) -> Result<(), LayoutError> {
395    if node1_id == node2_id {
396        return Err(LayoutError::SelfSwap);
397    }
398    if root.id == node1_id || root.id == node2_id {
399        return Err(LayoutError::RootCannotBeTarget);
400    }
401
402    // Collect both nodes + parent info.
403    let n1 = find_node_by_id(root, node1_id)
404        .ok_or_else(|| LayoutError::NodeNotFound { id: node1_id.to_string() })?
405        .clone();
406    let n2 = find_node_by_id(root, node2_id)
407        .ok_or_else(|| LayoutError::NodeNotFound { id: node2_id.to_string() })?
408        .clone();
409    let p1_id = find_parent_id(root, node1_id)
410        .ok_or_else(|| LayoutError::NodeNotFound { id: format!("parent of {}", node1_id) })?;
411    let p2_id = find_parent_id(root, node2_id)
412        .ok_or_else(|| LayoutError::NodeNotFound { id: format!("parent of {}", node2_id) })?;
413
414    // Detect ancestor/descendant relationship. If n1 is an ancestor of n2
415    // (or vice versa), the first placement replaces a subtree that contains
416    // the second node's parent, making the second lookup fail.
417    if find_node_by_id(&n1, node2_id).is_some() || find_node_by_id(&n2, node1_id).is_some() {
418        return Err(LayoutError::RootCannotBeTarget);
419    }
420
421    // Find indices.
422    let idx1 = {
423        let p = find_node_by_id(root, &p1_id).unwrap();
424        p.children.iter().position(|c| c.id == node1_id).unwrap()
425    };
426    let idx2 = {
427        let p = find_node_by_id(root, &p2_id).unwrap();
428        p.children.iter().position(|c| c.id == node2_id).unwrap()
429    };
430
431    // Build swapped nodes: preserve sizes AT the slot, swap the nodes.
432    let mut n1_swapped = n2.clone();
433    n1_swapped.size = n1.size; // slot 1 keeps size of what was there (n1's size)
434    let mut n2_swapped = n1.clone();
435    n2_swapped.size = n2.size; // slot 2 keeps size of what was there (n2's size)
436
437    // Place them. p2_id is guaranteed to still exist (ancestor check above).
438    let p1 = find_node_by_id_mut(root, &p1_id).unwrap();
439    p1.children[idx1] = n1_swapped;
440
441    let p2 = find_node_by_id_mut(root, &p2_id).unwrap();
442    p2.children[idx2] = n2_swapped;
443
444    Ok(())
445}
446
447/// Apply resize operations. Validates all sizes first (0.0–100.0 range);
448/// if any is invalid, returns an error WITHOUT applying any ops (atomically
449/// rejected — matches the frontend's early-return semantic from PR #686).
450pub fn resize_nodes(root: &mut LayoutNode, ops: &[ResizeOp]) -> Result<(), LayoutError> {
451    // Validation pass 1: sizes.
452    for op in ops {
453        if !(0.0..=100.0).contains(&op.size) {
454            return Err(LayoutError::InvalidSize {
455                node_id: op.node_id.clone(),
456                size: op.size,
457            });
458        }
459    }
460    // Validation pass 2: all target nodes exist.
461    for op in ops {
462        if find_node_by_id(root, &op.node_id).is_none() {
463            return Err(LayoutError::NodeNotFound { id: op.node_id.clone() });
464        }
465    }
466    // Apply pass (all valid).
467    for op in ops {
468        let node = find_node_by_id_mut(root, &op.node_id).unwrap();
469        node.size = op.size;
470    }
471    Ok(())
472}
473
474/// Replace the node at `target_id` with `new_node`, preserving the target's
475/// `size`. If target is root, replaces root-in-place (caller's `Option<LayoutNode>`
476/// doesn't change reference — the fields are updated on the root node).
477pub fn replace_node(
478    root: &mut LayoutNode,
479    target_id: &str,
480    new_node: LayoutNode,
481) -> Result<(), LayoutError> {
482    if root.id == target_id {
483        let preserved_size = root.size;
484        *root = new_node;
485        root.size = preserved_size;
486        return Ok(());
487    }
488    let parent_id = find_parent_id(root, target_id)
489        .ok_or_else(|| LayoutError::NodeNotFound { id: target_id.to_string() })?;
490    let parent = find_node_by_id_mut(root, &parent_id).unwrap();
491    let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
492    let preserved_size = parent.children[idx].size;
493    parent.children[idx] = new_node;
494    parent.children[idx].size = preserved_size;
495    Ok(())
496}
497
498/// Horizontal split: insert `new_node` before/after `target_id`.
499///
500/// - If the target's parent is a Row, splice directly.
501/// - Otherwise, wrap target + new_node in a fresh Row group.
502pub fn split_horizontal(
503    root: &mut LayoutNode,
504    target_id: &str,
505    new_node: LayoutNode,
506    position: SplitPosition,
507) -> Result<(), LayoutError> {
508    split_impl(root, target_id, new_node, position, FlexDirection::Row)
509}
510
511/// Vertical split: insert `new_node` before/after `target_id`.
512///
513/// - If the target's parent is a Column, splice directly.
514/// - Otherwise, wrap in a fresh Column group.
515pub fn split_vertical(
516    root: &mut LayoutNode,
517    target_id: &str,
518    new_node: LayoutNode,
519    position: SplitPosition,
520) -> Result<(), LayoutError> {
521    split_impl(root, target_id, new_node, position, FlexDirection::Column)
522}
523
524fn split_impl(
525    root: &mut LayoutNode,
526    target_id: &str,
527    new_node: LayoutNode,
528    position: SplitPosition,
529    direction: FlexDirection,
530) -> Result<(), LayoutError> {
531    // Find target; it must exist.
532    let _ = find_node_by_id(root, target_id)
533        .ok_or_else(|| LayoutError::NodeNotFound { id: target_id.to_string() })?;
534
535    let parent_id = find_parent_id(root, target_id);
536
537    if let Some(ref pid) = parent_id {
538        let parent = find_node_by_id_mut(root, pid).unwrap();
539        if parent.flex_direction == direction {
540            // Parent already has the matching direction — splice directly.
541            let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
542            let insert_at = match position {
543                SplitPosition::Before => idx,
544                SplitPosition::After => idx + 1,
545            };
546            parent.children.insert(insert_at, new_node);
547            return Ok(());
548        }
549        // Parent has wrong direction — wrap target in a new group.
550        let idx = parent.children.iter().position(|c| c.id == target_id).unwrap();
551        let target = parent.children.remove(idx);
552        let target_size = target.size;
553        let (children_ordered, new_flex) = match position {
554            SplitPosition::Before => (vec![new_node, target], direction),
555            SplitPosition::After => (vec![target, new_node], direction),
556        };
557        let group = LayoutNode {
558            id: Uuid::new_v4().to_string(),
559            flex_direction: new_flex,
560            size: target_size,
561            children: children_ordered,
562            ..Default::default()
563        };
564        parent.children.insert(idx, group);
565        return Ok(());
566    }
567
568    // Target IS root (no parent) — wrap root in a new group.
569    // We can't replace `root` itself, so we build the group children in-place.
570    // Also take `root.extra` (unknown-field catch-all from #688/#689).
571    // `..Default::default()` would silently drop it — reagent P1 PR #691.
572    let root_clone = LayoutNode {
573        id: root.id.clone(),
574        flex_direction: root.flex_direction,
575        size: root.size,
576        children: std::mem::take(&mut root.children),
577        data: root.data.take(),
578        extra: std::mem::take(&mut root.extra),
579    };
580    let root_size = root_clone.size;
581    let new_group_id = Uuid::new_v4().to_string();
582    let (children_ordered, new_flex) = match position {
583        SplitPosition::Before => (vec![new_node, root_clone], direction),
584        SplitPosition::After => (vec![root_clone, new_node], direction),
585    };
586    root.id = new_group_id;
587    root.flex_direction = new_flex;
588    root.size = root_size;
589    root.children = children_ordered;
590    root.data = None;
591    Ok(())
592}
593
594// ── Clear tree ─────────────────────────────────────────────────────────────
595
596/// Clear the tree root. Callers set the `Option<LayoutNode>` to `None`.
597/// This function exists only to be referenced in the test helper pattern;
598/// actual clearing in the reducer just sets root = None.
599pub fn clear_tree_node(_root: &mut Option<LayoutNode>) {
600    *_root = None;
601}
602
603// ── Tests ──────────────────────────────────────────────────────────────────
604#[cfg(test)]
605mod tests;